Asynchrone JavaScript
Asynchroon vs Synchroon
Wanneer je code schrijft in JavaScript zal je wel eens horen zeggen dat JavaScript een "single-threaded taal" is. Wat wilt dat nu juist zeggen? Voor we dit uitleggen, beginnen we met de definitie van een paar begrippen waar we naar gaan verwijzen.
Thread verwijst hier naar gesloten context waarbinnen de code uitgevoerd wordt en die verantwoordelijk is om het programma te runnen.
De memory heap is waar naar verwezen wordt als we het hebben over hoe en waar JavaScript geheugen gebruikt.
De call stack is de lijst van instructies die uitgevoerd moeten worden en de volgorde waarin ze uitgevoerd moeten worden.
Code wordt lijn per lijn in de call stack gestoken en uitgevoerd. De call stack werkt First In Last Out (FILO), wat wilt zeggen dat als je een functie hebt die andere functies oproept, die op zijn beurt andere functies oproept, ... JavaScript die van binnen naar buiten gaat uitvoeren.
Bijvoorbeeld:
function function1() { // function1 wordt op de call stack geduwd
function2();
}
function function2() { // function2 wordt op de call stack geduwd
function3()
}
function function3() { // function3 wordt op de call stack geduwd
return "De eerste die uitgevoerd wordt!";
}
console.log(function1()) // Console.log wordt op de call stack geduwd
Als je dit programma runt, dan begint de code met console.log(function1()). function1 roept op zijn beurt function2 aan en die roept dan weer function3 aan. function3 heeft een "uitvoerbaar stukje code" dus dat is het eerste dat er gaat gebeuren wanneer het programma effectie aan het runnen is. Dus dan ziet de callstack eruit als volgt wanneer het programma begint te runnen:
return "De eerste die uitgegvoerd wordt"
function3()
function2()
function1()
console.log()
JavaScript is een single-threaded taal, wat betekent dat er maar 1 thread is waarin alle code uitgevoerd moet worden. Als je niet weet wat een thread is, vergelijk het met een wachtrij aan een loket. Elke lijn code in de callstack staat aan te schuiven aan hetzelfde loket en dus elk stukje code moet wachten tot het vorige stuk uitgevoerd is, voor het aan de beurt is.
De conclusie die we hieruit kunnen trekken is dat JavaScript een synchrone taal is per definitie. De term synchroon in het programmeren betekent namelijk dat de code regel per regel uitgevoerd wordt.
Dit is niet handig. Als je bijvoorbeeld een stuk code zou introduceren dat niet erg performant is, gaat die de thread blokkeren en ervoor zorgen dat het programma vasthangt tot de uitvoering van dat stukje helemaal klaar is. Vergelijk het met iemand die heel veel tijd nodig heeft aan het loket, waardoor de rest van de rij, verplicht moet wachten. Zelfs als je zelf maar heel even nodig hebt om geholpen te worden.
Neem nu bijvoorbeeld de volgende code:
function telTotHeelVeel() {
for(let i = 0; i <= 1000000000; i++) {
console.log(i)
}
}
function belangrijkeCode() {
return "Dit moet ik snel weten";
}
telTotHeelVeel();
console.log(belangrijkeCode());
Voor ik de uitkomst van belangrijkeCode kan achterhalen moet ik eerst wachten tot telTotHeelVeel tot .. heel veel geteld heeft.
De functie telTotHeelVeel is natuurlijk niet meteen iets wat we in het echt zouden tegekomen maar vervang hem denkbeeldig door een functie die veel rekenkracht nodig heeft (en dus lang duurt). De conclusie die je hier als frontend developer kan trekken is dat functies die veel rekenkracht nodig hebben best naar de backend verplaatst worden. Daar zijn nog andere redenen voor, maar dit is er zeker 1 van.
Asynchroon gedrag
Hoewel JavaScript een taal is die zich van nature synchroon gedraagt (single-threaded is) kan het toch asynchroon gedrag vertonen.
Neem nu volgende code:
function returnA() {
return "A";
}
function returnB() {
setTimeout(() => {
return "B";
}, 3000);
}
function returnC() {
return "C";
}
console.log(returnA());
console.log(returnB());
console.log(returnC());
Als je deze code uitvoert, zal je zien dat ze het volgende uitprint:
A
undefined
B
Wat is er aan de hand?
Return B gaat zich asynchroon gedragen omwille van de setTimeout. Dit is een ingebouwde JS functie waar je een functie en een tijd (in milliseconden) aan meegeeft. Als x aantal milliseconden afgelopen zijn, dan wordt de functie die je meegegeven hebt uitgevoerd. In dit geval zal returnB dus pas "B" returnen na 2 seconden (of 2000 milliseconden). De synchrone thread van JS wacht hier echter niet op en op het moment dat we console.log(returnB()) tegenkomen is er niks gereturned, en print de console dus undefined. De reden hiervoor is dat het asynchroon gedrag zich buiten de JavaScript engine afspeelt.
Om uit te leggen wat hier gebeurt moeten we eerst nog 3 andere belangrijke begrippen uitleggen: WEB APIs, de callback queue en de event loop.
**Web API's is een set van functies die gedefinieerd worden door de JavaScript runtime waar je JavaScript code in uitgevoerd wordt. De 2 belangrijkste runtime environments zijn de browser en NodeJS (voor de backend). Als frontend developers zijn jullie vooral geïnteresseerd in de runtime environment van de browser. De belangrijkste WEB APIs hier zijn TimeOut voor alle scheduling (setTimeout, setInterval, ...), HTTP requests en DOM (bv MouseEvents).
De callback queue is een wachtrij. Wanneer een functie uitgevoerd door de Web APIs klaar is, zal het de uitkomst van zijn uitvoering in de callback queue steken.
De event loop is de dirigent. Hij gaat heen en weer tussen de call stack en de callback queue en bepaalt wie er aan de beurt is om iets uit te voeren.
Wanneer we bovenstaande code uitvoeren gebeurt er het volgende:
- JS leest
console.log(returnA()). Het duwt het boven op de callstack en ziet dat het de code kan uitvoeren en doet dat vervolgens ook. - JS leest
console.log(returnB()). Wanneer we inreturnBkomen vinden we eensetTimeOut(). Dit maakt deel uit van de WEB APIs. Javascript stuurt dit dus door naar WEB APIs. - Intussen gaat de thread gewoon verder en vermits op dat moment
returnBnog niks kan returnen, is dit voor JavaScript gewoonundefined - De call stack is terug leeg dus het leest de volgende regel
console.log(returnC()). Het zet het op de call stack en voert het uit. - Intussen voert WEB APIs de
setTimeOutuit en zet het resultaat in de callback queue. - De event loop ziet dat de call stack leeg is en dat er iets in de callback queue zit en de event loop haalt het resultaat van de queue.
Het probleem hier is echter, dat op het moment dat deze waarde uit de queue gelezen kan worden, er niks mee gedaan wordt. De console.log(returnB) is immers al lang uitgevoerd want JS wist niet dat erop gewacht moest worden... .
Promises
Promises zijn een manier in JavaScript om ervoor te zorgen dat asynchrone code zich synchroon gedraagt. Het geeft ons een middel om te wachten op het resultaat dat uiteindelijk in de callback queue komt. Wanneer we een Promise returnen, dan returnen we eigenlijk zoals het woord verraadt een belofte dat we op een bepaald moment terug zullen komen met ofwel een waarde, ofwel een error wanneer er iets misgelopen is.
Een Promise kan in 1 van 3 states zijn. Ze is ofwel:
- pending = in afwachting van het resultaat
- fulfilled = succesvol uitgevoerd, ook wel bekend als resolved
- rejected = niet succesvol uitgevoerd
Promises hebben 3 ingebouwde methodes waar we gebruik van kunnen maken om ervoor te zorgen dat er iets kan worden uitgevoerd pas nadat de Promise resolved of reject.
then()
Een Promise heeft een methode (=functie op de class) then(). Dit kunnen we gebruiken om te wachten op het resultaat van een succesvol uitgevoerde promise en daar iets mee te doen. Bijvoorbeeld als we de code van hierboven nemen waar console.log(returnB()) een undefined waarde ging afprinten en we willen dit omvormen zodat er met behulp van een Promise toch B naar de console wordt geprint.
function returnA() {
return "A";
}
function returnB() {
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("B");
}, 3000);
})
}
function returnC() {
return "C";
}
console.log(returnA());
returnB().then(function(result) {
console.log(result);
});
console.log(returnC());
Het resultaat wanneer we deze code uitvoeren is:
A
C
B
Dit komt overeen met wat hierboven uitgelegd staat ivm de call stack. Het verschil is dat we nu wachten tot de functie die in de Promise zit effectief een waarde teruggeeft (resolved) en die waarde dan kunnen gebruiken.
Stel nu dat we toch echt heel graag willen dat onze code dit afprint:
A
B
C
Dan moeten we eerst wachten tot de waarde van returnB beschikbaar is en dan pas console.log("C") uitvoeren.
function returnA() {
return "A";
}
function returnB() {
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("B");
}, 3000);
})
}
function returnC() {
return "C";
}
console.log(returnA());
returnB().then(function(result) {
console.log(result);
console.log(returnC());
});
catch()
Stel nu dat een promise niet resolved maar reject. Dan kunnen we deze error opvangen met een catch() functie.
const asynchroneFunctie = new Promise(function(resolve, reject) {
setTimeout(() => {
reject("error");
}, 3000);
})
asynchroneFunctie.then((resultaat) => {
console.log(resultaat) // Hier gaat hij NIET inkomen omdat de Promise reject
}).catch((error) => {
console.log(error) // "error" wordt hier afgeprint
})
finally()
Tot slot kan je er ook nog voor zorgen dat er bepaalde functionaliteit altijd uitgevoerd wordt, ongeacht of de Promise nu resolved of reject. Dit doen we met finally().
const asynchroneFunctie = new Promise(function(resolve, reject) {
setTimeout(() => {
reject("error");
}, 3000);
})
asynchroneFunctie.then((resultaat) => {
console.log(resultaat) // Hier gaat hij niet inkomen omdat de Promise reject
}).catch((error) => {
console.log(error) // "error" wordt hier afgeprint
}).finally(() => {
console.log("Dit wordt altijd uitgevoerd");
})
Promises chainen
Stel nu dat we het voorbeeld hadden van hierboven:
const asynchroneFunctie = new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("B");
}, 3000);
})
console.log('A');
// resultaat is wat er meegegegeven wordt aan de resolve functie van de Promise.
asynchroneFunctie.then((resultaat) => {
console.log(resultaat) // Print "B"
console.log('C'); // Print "C"
})
En in plaats van de waardes gewoon af te printen, willen we de waarde van returnB meegeven aan een andere functie die ook een Promise is. Dan kunnen we deze Promises chainen.
function geeftEenNaamTerug() {
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("Anneleen");
}, 3000);
})
}
function gebruikNaamVoorCompliment(naam) {
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve(`${naam} kan goed dansen!`);
}, 3000);
})
}
const naamPromise = geeftEenNaamTerug();
naamPromise.then((naam) => {
const complimentPromise = gebruikNaamVoorCompliment(naam);
complimentPromise.then((compliment) => {
console.log(compliment);
})
})
Om dit gedrag te vereenvoudigen zodat we leesbaardere code konden schrijven die niet eindeloos promises chainen, is in ES6 async/await geïntroduceerd.
Async / await
async function geeftEenNaamTerug() { // We markeren de functie met "async" keyword
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("Anneleen");
}, 3000);
})
}
async function gebruikNaamVoorCompliment(naam) {
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve(`${naam} kan goed dansen!`);
}, 3000);
})
}
async function geefCompliment() {
const naam = await geeftEenNaamTerug(); // Omdat het een "async" functie is kunnen we wachten op de resolved waarde met "await"
const compliment = await gebruikNaamVoorCompliment(naam);
console.log(compliment);
}
geefCompliment();
Wat gebeurt er in bovenstaande code? Omdat we weten dat geeftEenNaamTerug zich asynchroon gaat gedragen, kunnen we de functie markeren met een async keyword. Door dit te doen, kan in andere functie gewacht worden op het resultaat van de resolve functie met het await keyword.
Belangrijk hier is wel, dat we alleen maar await kunnen gebruiken in een functie die zelf ook met async gemarkeerd is. Dit is, omdat wanneer we in de functie gaan wachten op de uitvoering van een Promise in een andere functie, het gedrag van de functie waarin we wachten ook automatisch asynchroon wordt.
Error handling in async/await
Zoals hierboven gezegd, gaat await er alleen maar voor zorgen dat we wachten op de waarde van de resolve. Wat als de Promise waar we op wachten dan reject? Dit gaan we opvangen met een try/catch block. try/catch wilt zoveel zeggen als: "ik probeer uit te voeren wat er in de "try" block staat, en als dat niet gaat, ga ik naar de "catch" block".
async function geeftEenNaamTerug() { // We markeren de functie met "async" keyword
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("Anneleen");
}, 3000);
})
}
async function gebruikNaamVoorCompliment(naam) {
return new Promise(function(resolve, reject) {
setTimeout(() => {
if (!naam) {
reject("Er werd geen naam meegegeven");
}
resolve(`${naam} kan goed dansen!`);
}, 3000);
})
}
async function geefCompliment() {
try {
const naam = await geeftEenNaamTerug(); // Omdat het een "async" functie is kunnen we wachten op de resolved waarde met "await"
const compliment = await gebruikNaamVoorCompliment();
console.log(compliment);
} catch (error) {
console.log(error);
}
}
geefCompliment();
Als je bovenstaande code uitvoert dan zal je zien dat er "Er werd geen naam meegegeven" gelogd wordt. Terecht, want we zijn vergeten de naam variable als input mee te geven aan gebruikNaamVoorCompliment.
Promises vs async/await
Als er async/await bestaat, waarom zouden we dan nog Promises gebruiken? Async/await lijkt veel leesbaarder en eenvoudiger om te gebruiken. Toch zijn er situaties waarin je eerder voor Promises zou gaan.
Neem bijvoorbeeld volgende code:
async function geeftEenNaamTerug() { // We markeren de functie met "async" keyword
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("Anneleen");
}, 3000);
})
}
async function geeftEenAdresTerug() {
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve(`Sesaamstraat 1, 1234 AB Sesamstad`);
}, 3000);
})
}
async function geefAdresInfo() {
try {
const naam = await geeftEenNaamTerug(); // Omdat het een "async" functie is kunnen we wachten op de resolved waarde met "await"
const adres = await geeftEenAdresTerug();
console.log(`${naam} woont op ${adres}`);
} catch (error) {
console.log(error);
}
}
geefAdresInfo();
Deze code zal ruwweg 6 seconden nodig hebben om een console.log af te printen met de boodschap "Anneleen woont op Sesaamstraat 1, 1234 AB Sesamstad". Dit is omwille van de aard van async/await. De await zal er namelijk voor zorgen dat de code letterlijk wacht op het resultaat van de eerste Promise, dan de tweede functie aanroept en wacht op dat resultaat en vervolgens pas naar de console.log gaat. Beide functies zouden echter in parallel uitgevoerd kunnen worden, vermits ze geen data van elkaar nodig hebben.
Hiervoor kunnen we Promise.all gebruiken. Aan Promise.all kan je een Array van Promises meegeven en de functie zal returnen als ze allebei uitgevoerd zijn. De functie zorgt ervoor dat taken die naar de Web API gestuurd worden, geparalleliseerd kunnen worden.
async function geeftEenNaamTerug() { // We markeren de functie met "async" keyword
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve("Anneleen");
}, 3000);
})
}
async function geeftEenAdresTerug() {
return new Promise(function(resolve, reject) {
setTimeout(() => {
resolve(`Sesaamstraat 1, 1234 AB Sesamstad`);
}, 3000);
})
}
async function geefAdresInfo() {
const naam = geeftEenNaamTerug();
const adres = geeftEenAdresTerug();
Promise.all([naam, adres])
.then(results => {
console.log(`${results[0]} woont op het adres: ${results[1]}`);
});
}
geefAdresInfo();
Moest je bovenstaande code uitvoeren, zal je zien dat deze ongeveer 3 seconden nodig heeft om "Anneleen woont op Sesaamstraat 1, 1234 AB Sesamstad" af te printen.
Oefeningen
Oefening 1 - Promises
- Schrijf een functie
fetchDatadie een Promise returned. De promise moet resolven met een boodschap"Data fetched"na 2 seconden. - Roep de functie
fetchDataop en print het resultaat van de resolved value af met.then().
Oefening 2 - Promises "chainen"
- Pas de functie
fetchDataaan zodat het een object teruggeeft. Het object stelt een restaurant voor en heeft een eigenschap"id"en een eigenschap"naam".
{
"id": 1,
"naam": "McDonalds Hasselt"
}
- Maak een 2de functie
fetchReviewsdie het object dat doorfetchDataals parameter mee krijgt.fetchReviewsreturned zelf een Promise die na 2 seconden resolved en een object teruggeeft met als eigenschappen"id","naam"(dezelfde als van de input parameter) en"reviews"."reviews"is een array van 10 getallen met een waarde tussen 1 en 5 (mag hardcoded zijn) die punten voorstellen van user reviews.
{
"id": 1,
"naam": "McDonalds Hasselt,
"reviews": [1, 3, 5, 1, 4, 2, 5, 5, 5, 4]
}
- Chain de promises nu aan elkaar zodat je eerst het restaurant ophaalt en dan de reviews. Gebruik
then().
Oefening 3 - finally() en catch()
- Pas
fetchDataaan zodat het soms reject met een error. Dit kan je bijvoorbeeld doen op basis van een getal dat je meegeeft zoals in de les (groter dan / kleiner dan check) of door een random getal te genereren metMath.random()en dit in je if statement te gebruiken of ... . - Voeg error handling toe aan de functie die fetchData oproept door middel van een
catch(). - Gebruik
finally()om een bericht naar de console te printen wanneer de promise vanfetchDatareturned, ongeacht of het een succes of een error is.
Oefening 4: Promises naar async / await
- Herschrijf de Promise based code van oefening 2 naar code die
asyncenawaitgebruikt.
Oefening 5: Async/Await Error Handling
- Pas de async/await code van Oefening 4 aan zodat 1 van de 2 functies (
fetchDataoffetchReviews) reject met een error. - Gebruik
try/catchom de error op te vangen en correct weer te geven in de console.
Oefening 6: Meerdere Promises in parallel
- Schrijf 2 functies:
fetchGamesenfetchCharacters. Beide returnen een Promise die resolved na 1 seconde met data.fetchGamesgeeft een Array van games terug, bijvoorbeeld:["FIFA2024", "D&D"].fetchCharactersgeeft een lijst met characters in verschillende spellen terug. - Gebruik
Promise.all()om de data van beide op te halen en log ze naar de console.
Oefening 7: Vergelijking tussen Promise.all en async / await
- Schrijf 2 asynchrone functies
fetchData1enfetchData2. Elke returned een Promise die resolved na 2 seconden. - Probeer eerst de data van beide Promises op te halen met
Promise.allen daarna metasync/await. Vergelijk de tijd die beide versies nodig hebben met elkaar.
Oefeningen - Real app
https://github.com/anneleenscholts/joke-generator-start